diff options
Diffstat (limited to 'web/pw-server/src/routes/bots/[bot_name]')
-rw-r--r-- | web/pw-server/src/routes/bots/[bot_name]/index.svelte | 176 | ||||
-rw-r--r-- | web/pw-server/src/routes/bots/[bot_name]/stats.svelte | 169 |
2 files changed, 345 insertions, 0 deletions
diff --git a/web/pw-server/src/routes/bots/[bot_name]/index.svelte b/web/pw-server/src/routes/bots/[bot_name]/index.svelte new file mode 100644 index 0000000..6e93834 --- /dev/null +++ b/web/pw-server/src/routes/bots/[bot_name]/index.svelte @@ -0,0 +1,176 @@ +<script lang="ts" context="module"> + import { ApiClient } from "$lib/api_client"; + + export async function load({ params, fetch }) { + const apiClient = new ApiClient(fetch); + + try { + const bot_name = params["bot_name"]; + const [botData, botStats, matchesPage] = await Promise.all([ + apiClient.get(`/api/bots/${bot_name}`), + apiClient.get(`/api/bots/${bot_name}/stats`), + apiClient.get("/api/matches", { bot: params["bot_name"], count: "20" }), + ]); + + const { bot, owner, versions } = botData; + versions.sort((a: string, b: string) => + dayjs(a["created_at"]).isAfter(b["created_at"]) ? -1 : 1 + ); + return { + props: { + bot, + owner, + versions, + botStats, + matches: matchesPage["matches"], + }, + }; + } catch (error) { + return { + status: error.status, + error: error, + }; + } + } +</script> + +<script lang="ts"> + import dayjs from "dayjs"; + import { currentUser } from "$lib/stores/current_user"; + import MatchList from "$lib/components/matches/MatchList.svelte"; + import LinkButton from "$lib/components/LinkButton.svelte"; + + export let bot: object; + export let owner: object; + export let versions: object[]; + export let matches: object[]; + export let botStats: object; + // function last_updated() { + // versions.sort() + // } + + // let files; + + // async function submitCode() { + // console.log("click"); + // const token = get_session_token(); + + // const formData = new FormData(); + // formData.append("File", files[0]); + + // const res = await fetch(`/api/bots/${bot["id"]}/upload`, { + // method: "POST", + // headers: { + // // the content type header will be set by the browser + // Authorization: `Bearer ${token}`, + // }, + // body: formData, + // }); + + // console.log(res.statusText); + // } +</script> + +<!-- +<div>Upload code</div> +<form on:submit|preventDefault={submitCode}> + <input type="file" bind:files /> + <button type="submit">Submit</button> +</form> --> + +<div class="container"> + <div class="header"> + <h1 class="bot-name">{bot["name"]}</h1> + {#if owner} + <a class="owner-name" href="/users/{owner['username']}"> + {owner["username"]} + </a> + {/if} + </div> + + {#if $currentUser && $currentUser["user_id"] === bot["owner_id"]} + <div> + <!-- TODO: can we avoid hardcoding the url? --> + Publish a new version by pushing a docker container to + <code>registry.planetwars.dev/{bot["name"]}:latest</code>, or using the web editor. + </div> + + <div class="versions"> + <h3>Versions</h3> + <ul class="version-list"> + {#each versions.slice(0, 10) as version} + <li class="bot-version"> + {dayjs(version["created_at"]).format("YYYY-MM-DD HH:mm")} + {#if version["container_digest"]} + <span class="container-digest">{version["container_digest"]}</span> + {:else} + <a href={`/code/${version["id"]}`}>view code</a> + {/if} + </li> + {/each} + </ul> + {#if versions.length == 0} + This bot does not have any versions yet. + {/if} + </div> + {/if} + + <div class="matches"> + <h3>Recent matches</h3> + <MatchList {matches} /> + {#if matches.length > 0} + <div class="btn-container"> + <LinkButton href={`/matches?bot=${bot["name"]}`}>All matches</LinkButton> + </div> + {/if} + </div> +</div> + +<style lang="scss"> + .container { + width: 800px; + max-width: 80%; + margin: 50px auto; + } + + .header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 60px; + border-bottom: 1px solid black; + } + + $header-space-above-line: 12px; + + .bot-name { + font-size: 24pt; + margin-bottom: $header-space-above-line; + } + + .owner-name { + font-size: 14pt; + text-decoration: none; + color: #333; + margin-bottom: $header-space-above-line; + } + + .btn-container { + padding: 24px; + text-align: center; + } + + .versions { + margin: 30px 0; + } + + .version-list { + padding: 0; + } + + .bot-version { + display: flex; + justify-content: space-between; + padding: 4px 24px; + } +</style> diff --git a/web/pw-server/src/routes/bots/[bot_name]/stats.svelte b/web/pw-server/src/routes/bots/[bot_name]/stats.svelte new file mode 100644 index 0000000..6b5a2e1 --- /dev/null +++ b/web/pw-server/src/routes/bots/[bot_name]/stats.svelte @@ -0,0 +1,169 @@ +<script lang="ts" context="module"> + import { ApiClient } from "$lib/api_client"; + + export async function load({ params, fetch }) { + const apiClient = new ApiClient(fetch); + + try { + const bot_name = params["bot_name"]; + const [botData, botStats, leaderboard] = await Promise.all([ + apiClient.get(`/api/bots/${bot_name}`), + apiClient.get(`/api/bots/${bot_name}/stats`), + apiClient.get("/api/leaderboard"), + ]); + + const { bot, owner } = botData; + return { + props: { + bot, + owner, + botStats, + leaderboard, + }, + }; + } catch (error) { + return { + status: error.status, + error: error, + }; + } + } + + function mergedStats(rawStats: object) { + return Object.fromEntries( + Object.entries(rawStats).map(([opponent, ms]) => { + const mapStats = ms as { k: MatchupStats }; + return [opponent, Object.values(mapStats).reduce(mergeStats)]; + }) + ); + } + + type MatchupStats = { + win: number; + tie: number; + loss: number; + }; + + function winRate(stats: MatchupStats) { + return (stats.win + 0.5 * stats.tie) / (stats.win + stats.tie + stats.loss); + } + + function mergeStats(a: MatchupStats, b: MatchupStats): MatchupStats { + return { + win: a.win + b.win, + tie: a.tie + b.tie, + loss: a.loss + b.loss, + }; + } +</script> + +<script lang="ts"> + export let bot: object; + export let owner: object; + export let botStats: object; + export let leaderboard: object[]; + + $: mergedStats = mergedStats(botStats); +</script> + +<div class="container"> + <div class="header"> + <h1 class="bot-name">{bot["name"]}</h1> + {#if owner} + <a class="owner-name" href="/users/{owner['username']}"> + {owner["username"]} + </a> + {/if} + </div> + <h2>Stats</h2> + <table class="leaderboard"> + <tr class="leaderboard-row leaderboard-header"> + <th class="leaderboard-rank">Rank</th> + <th class="leaderboard-rating">Rating</th> + <th class="leaderboard-bot">Bot</th> + <th class="leaderboard-author">Author</th> + <th>Winrate</th> + <th>Matches</th> + </tr> + {#each leaderboard as entry, index} + <tr class="leaderboard-row"> + <td class="leaderboard-rank">{index + 1}</td> + <td class="leaderboard-rating"> + {entry["rating"].toFixed(0)} + </td> + <td class="leaderboard-bot"> + <a class="leaderboard-href" href="/bots/{entry['bot']['name']}" + >{entry["bot"]["name"]} + </a></td + > + <td class="leaderboard-author"> + {#if entry["author"]} + <!-- TODO: remove duplication --> + <a class="leaderboard-href" href="/users/{entry['author']['username']}" + >{entry["author"]["username"]}</a + > + {/if} + </td> + {#if mergedStats[entry["bot"]["name"]]} + <td> + {winRate(mergedStats[entry["bot"]["name"]]).toFixed(2)} + </td> + <td> + <a href={`/matches?bot=${bot["name"]}&opponent=${entry["bot"]["name"]}`}>view matches</a + > + </td> + {:else} + <td /> + <td>no matches yet </td>{/if} + </tr> + {/each} + </table> +</div> + +<style lang="scss"> + .container { + width: 800px; + max-width: 80%; + margin: 50px auto; + } + + .header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 60px; + border-bottom: 1px solid black; + } + + $header-space-above-line: 12px; + + .bot-name { + font-size: 24pt; + margin-bottom: $header-space-above-line; + } + + .owner-name { + font-size: 14pt; + text-decoration: none; + color: #333; + margin-bottom: $header-space-above-line; + } + + .leaderboard { + margin: 18px 10px; + text-align: center; + } + + .leaderboard th, + .leaderboard td { + padding: 8px 16px; + } + .leaderboard-rank { + color: #333; + } + + .leaderboard-href { + text-decoration: none; + color: black; + } +</style> |